fix(driver-memory): analytics honours the declared AnalyticsQuery.timezone when resolving a string dateRange (#16042) - #16174
Conversation
…mezone` when resolving a string `dateRange` (#16042) `parseDateRangeString()` accepted the field and never read it, so a caller asking `dateRange: 'today'` with `timezone: 'Asia/Shanghai'` was answered on the UTC day — accepted, unwarned, and silently ignoring the field they set. The schema declares `timezone` optional with no default precisely because an absent value is a meaningful state the engine resolves (`selection.timezone ?? context.timezone ?? 'UTC'`, ADR-0053 Phase 2), and `service-analytics` resolves that whole chain into `query.timezone` before a driver sees it. Two halves, each silent on its own and each pinned: the zone decides WHICH calendar day `'today'` is (`calendarPartsInTzOrUtc`, the `proxyDay()` pattern) and WHERE that day begins as an instant (`zonedDateStartToUtcMs` — that zone's local midnight, which is what ADR-0053 already specifies for a `datetime` bound in `service-analytics`' drill ranges). Half one alone anchors to the zone's calendar day and then cuts it at UTC midnight: a window that is neither day. The end bound is a calendar step, never `+ 86_400_000` — on `America/New_York` 2026-03-08 is 23 hours long. A query carrying no timezone is unchanged by construction, and #15825's two pins stay green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 8fed2e63a5e602e0705946579954e0183dbd7a1c && git checkout 8fed2e63a5e602e0705946579954e0183dbd7a1c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 07f40e5463a98d43f0106e34ab7ebb3a44d1de06 1328902ce92e6795aa3f190a296e16ad3eee648f && git checkout -B drift-repro 07f40e5463a98d43f0106e34ab7ebb3a44d1de06 && git merge --no-ff 1328902ce92e6795aa3f190a296e16ad3eee648f
node scripts/docs-audit/affected-docs.mjs --json 07f40e5463a98d43f0106e34ab7ebb3a44d1de06 |
Fixes #16042
packages/drivers/driver-memory/src/memory-analytics.tsdeclaredAnalyticsQuery.timezoneon its way in and never read it, sodateRange: 'today'was answered on the UTC calendar for a caller who had said which calendar they meant. Direction chosen: the driver HONOURS the field.Why honour it rather than stop advertising it
The card states the choice as binary — honour it, or stop advertising it on this path — and "a third state where it is accepted and ignored is the one that misleads." Stopping the advertisement is not available to this card, and not merely by dispatch fence:
packages/spec/src/data/analytics.zod.ts, another lane this card does not edit.timezone, which every current caller passes legitimately —service-analytics'buildQuerywrites a timezone into everyAnalyticsQueryit builds (selection.timezone ?? contextTimezone ?? 'UTC',dataset-executor.ts:1121). A refusal there would reject the platform's own normal traffic.So the repair is agreement, and the driver owes the last two links of the declared chain: the value it was handed, else UTC.
Before / after row sets — a non-UTC zone
Asia/Shanghai, clock frozen at2026-09-06T20:00:00Z(04:00 on 2026-09-07 in Shanghai),dateRange: 'today', eight probe rows. Both row sets are asserted against the realMemoryAnalyticsService.query()entry in the same test, so the "before" is measured, not recalled.[2026-09-06T00:00:00.000Z, 2026-09-07T00:00:00.000Z)— the UTC day06T00:00:00.000Z·06T15:59:59.999Z·06T16:00:00.000Z·06T23:59:59.999Z[2026-09-06T16:00:00.000Z, 2026-09-07T16:00:00.000Z)— Shanghai's day06T16:00:00.000Z·06T23:59:59.999Z·07T04:00:00.000Z·07T15:59:59.999ZFour rows either way, two of the four different:
06T00:00:00.000Zand06T15:59:59.999Zleave (yesterday in Shanghai),07T04:00:00.000Zand07T15:59:59.999Zjoin (today in Shanghai). This is the change the card warned is not a drive-by.A query carrying no timezone is unchanged by construction, and #15825's two pins stay green (52 tests across the three date-range files).
The repair has two halves and each fails silently alone
'today'is —calendarPartsInTzOrUtc(now, tz), then arithmetic on a UTC proxy day (theproxyDay()pattern inpackages/core/src/utils/filter-tokens.ts:167).zonedDateStartToUtcMs(ymd, tz), that zone's local midnight.Half 2 is required because the bounds here are rendered with
toISOString()and compared against datetime values. That is exactly the case ADR-0053 already settles, in the only other place in this tree that turns a reference timezone into an instant bound —packages/services/service-analytics/src/analytics-service.ts:1271-1302:const rangeTz = selection.timezone ?? context?.timezone ?? 'UTC', then "datetime→ the reference tz's MIDNIGHT INSTANT (ISO), because the bucket is defined on that tz's calendar", with the bareYYYY-MM-DDcalendar bound reserved for adate-typed, tz-naive column.⛔ Half 1 alone is a silent wrong answer, and the pin proves it: see ablation B below, where
Asia/Kolkatadegenerates to exactly the UTC row set.⛔ The end bound is a calendar step, never
+ 86_400_000: onAmerica/New_York, 2026-03-08 begins at05:00Zand 2026-03-09 at04:00Z— a 23-hour day.Anchors and real line numbers (at
1328902ce)packages/drivers/driver-memory/src/memory-analytics.ts:1415—parseDateRangeString(range, timezone):742— now passesquery.timezonepackages/spec/src/data/analytics.zod.ts:347(timezone: z.string().optional(), doc at:337-346) — ⛔ not editedpackages/services/service-analytics/src/dataset-executor.ts:1121packages/services/service-analytics/src/analytics-service.ts:1271packages/core/src/utils/datetime.ts:62and:94proxyDay()packages/core/src/utils/filter-tokens.ts:167packages/drivers/driver-memory/src/memory-analytics-date-range-timezone.test.tsThe card's own re-check recipe self-falsifies — re-checked correctly
The card says⚠️ Out of scope here, filed separately" pointing at this card. Re-checked by searching for a READ instead:
git grep -n 'timezone' -- .../memory-analytics.ts→ 0 hits (at2024eca4f). Today it returns 5, and all five are comments added by #15825, one of them literally "git grep -nE "\.timezone|timezone\s*[:)]"hit only those same two comment lines; controldateRange= 4 in the same file, so the grep fires. Zero reads confirmed.Verification
Intl.DateTimeFormatalone.pnpm --filter @objectstack/driver-memory exec vitest runover the new pin +memory-analytics-date-range-utc-window+memory-analytics-date-range-dst+memory-analytics— 74 passed / 74.driver-memoryanalyticsparseDateRangeString()builds its window on the LOCAL calendar and renders it as UTC — 'today' and 'last N days' are offset by the process timezone in every non-UTC zone #15825's two pins stayed green — the new pin does not overlap their territory. Mutation proved on disk (blobe2a48c2f→be1295d0); restore proved (blob back to the HEAD blob,git diff HEADempty).proxyDay()-only — resolve the day in the zone, cut it at UTC midnight): 9 failures.Asia/Kolkatareturned[06T00:00:00.000Z … 06T23:59:59.999Z]— the UTC day exactly, which is what makes half 2 load-bearing rather than decorative. Mutation and restore proved the same way.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(43 by path + 6 by change kind + 7 whole-tree, 2 reached both ways), run with redirect-then-read at head1328902ce— 54/54 exit 0. The anchor/census family is included because this diff changes line counts:check-system-context-censusreports "105 elevation read sites … all anchored; 140 anchors resolve".check:dts-closureswept 71 built packages andpackages/drivers/driver-memory/dist/index.d.tsexists, so its green is a statement about this package.Clause ② —
no, re-derived from this diffInstrument: build at head → revert
memory-analytics.tstoorigin/main→ rebuild (tsupdirectly, no turbo cache on the path) → diff every filefiles[]publishes → restore byte-exact. Rebuild proved by mtime movement (index.d.ts1788667498→1788668359→1788668363) with the.jshash genuinely changing between snapshots.dist/index.d.ts0dc3accc…all three snapshots)dist/index.d.mtsdist/index.js,dist/index.mjsdist/index.js.map,dist/index.mjs.mapsourcesContentembedsmemory-analytics.tsverbatim, so every touched comment appears there. Not a published contract face.⛔ The byte-identical declaration is not taken on faith — the two known false negatives were excluded by measurement, not by shape:
dist/index.d.tsis 1031 lines with 23export/declarelines and 0 re-export-only lines. Firing control:MemoryAnalyticsServiceappears 5× in it.dist/is exactly 6 files, allindex.*. There is no chunk for a member to hide in.private parseDateRangeString, and TypeScript emits a private member as a bare name with no signature —private parseDateRangeString;atdist/index.d.ts:769, identical line and position in both snapshots. The added parameter is invisible to the declaration by construction..test./.spec., and neither namesdate-range-timezone.Limb 2 — is any request newly accepted or rejected? No.
AnalyticsQuerySchemais untouched, so every query that parsed before parses now. The driver adds no refusal path, no new error code and no new throw: both primitives degrade an unset,'UTC', or unknown zone to UTC rather than raising (measured live —Mars/Olympusyields UTC parts and UTC midnight), and the pin asserts a bogus zone still returns an answer. A query that was answered with UTC rows and is now answered with Shanghai rows is a change to the value returned for an accepted request, not to the accept set — which is why this needs aminorchangeset and the before/after table above, and is still a clause-②no.Docs drift — run by hand, because the check declares itself blind here
Docs Drift Checkreturned "nothing to list"; that is not a clean bill of health, so I ran the tool and then hand-searched.node scripts/docs-audit/affected-docs.mjs --json <merge-base>on a clean tree (dirty: false, head1328902ce, base94ecb7e5c) says:anchors= exactly one,parseDateRangeString(symbol, a method ofMemoryAnalyticsService);docs=[];anchorlessChanges=[];overbroadAnchors=[];testFilesSkipped= 1. ⭐weakAnchorsDropped=["query (symbol)"]— the name too generic to anchor isquery, the public method whose body carries the changed call site. It is nottimezone, which is the guess this shape invites.Hand-search of
content/docs(plain directory pathspec; 190 hand-written.mdxoutsidecontent/docs/references/**), since the check sees only pages that NAME an anchor:timeDimensions[].dateRangerelative-token vocabulary at all.dateRangeappears in 5 hand-written pages, none about this path; the three analytics pages (data-modeling/analytics.mdx,capabilities/analytics.mdx,ui/reports.mdx) contain 0 occurrences ofdateRange,timezone,UTC,timeDimensionorcalendar— with firing controls on each (analytics→ 8 / 1 / 10).content/docs/ui/dashboards.mdxis the one page that names range tokens (today,this_month,last_7_days,{today},{30_days_ago}). It makes no calendar claim whatsoever —utc0,timezone0,time zone0,calendar0 in that file (control:today→ 2). So there is nothing there for this change to falsify. Its vocabulary is also a different one: dashboard presets are underscored (last_7_days) and{today}is a core filter-token macro, whereas this path parses spaced strings ('last 7 days').always UTC,always resolved in UTC,interpreted as UTC,ignores timezone,UTC-only, …) → 0 hits each, against a firing control (UTC→ 35 hand-written pages). The 4not honouhits are MySQL upsert, email transport and boot assertions — unrelated.content/docs/data-modeling/queries.mdx:668-673says "Buckets are computed in UTC. A non-UTC reference timezone is only reachable throughObjectQL.aggregate(object, { …, timezone })". That is scoped togroupBy/dateGranularitybucketing on the ObjectQL path — a different path from the cubetimeDimensions[].dateRangewindow this PR changes. Measured support rather than asserted:git grep -n granularity -- packages/drivers/driver-memory/src/memory-analytics.tsreturns 0 —MemoryAnalyticsServicedoes no bucketing at all. Not falsified, and not edited.midnight UTCclaims inprotocol/objectql/query-syntax.mdxanddata-modeling/queries.mdxare ADR-0053's bare-YYYY-MM-DDwhole-day rule for filter comparands. Untouched:nextUtcCalendarDaystill returnsnullfor a full ISO timestamp, so the widening rule is unchanged.⇒ No documentation edit is owed by this PR, and nothing under
content/docs/releases/**was touched. The real gap the drift check is pointing at is that this vocabulary is undocumented — reported to the PM rather than fixed here, since writing that page is not this card.Scope
driver-memoryanalyticsparseDateRangeString()builds its window on the LOCAL calendar and renders it as UTC — 'today' and 'last N days' are offset by the process timezone in every non-UTC zone #15825 is not re-opened or redone. It made this path agree with the chain's terminal… ?? 'UTC'for every query carrying no timezone; this card is only the remaining supplied-timezone case. Its one now-narrower sentence was scoped in place, not rewritten.packages/specis not edited.driver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row — and the platform's own documented spelling'Last 7 days'is one of them #16041 — read it, it is not what a one-line summary of it suggests. It is titled "driver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row", and it carriesbug·priority:p2·needs-user-decision·domain:engine. The token-vocabulary-sharing question is one of its three open questions, not the whole of it. That matters here becausedriver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row — and the platform's own documented spelling'Last 7 days'is one of them #16041's defect lives in the same function this PR edits: thereturn [range, range]fallback for an unrecognised string.boundary()is invoked only inside the'today'and'last 'branches. The new resolution adds no throw on that path either:calendarPartsInTzOrUtcandzonedDateStartToUtcMsboth degrade an unknown zone to UTC rather than raising.YYYY-MM-DDcalendar day while this path emits an instant, and ADR-0053 gives those two different boundary rules on purpose. A shared vocabulary would have to carry that distinction, not erase it.driver-memoryanalytics silently accepts an unparseabledateRangeand matches EVERY row — and the platform's own documented spelling'Last 7 days'is one of them #16041 must carry this timezone resolution into whatever replaces the parser — replacingparseDateRangeStringwholesale would silently drop it again.🤖 Generated with Claude Code
https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Generated by Claude Code